Skip to content

feat(#912): MC/DC structural coverage over synth's own decision logic (RQ-57-MCDC) - #978

Merged
avrabe merged 5 commits into
mainfrom
feat/mcdc-structural-coverage-912
Aug 14, 2026
Merged

feat(#912): MC/DC structural coverage over synth's own decision logic (RQ-57-MCDC)#978
avrabe merged 5 commits into
mainfrom
feat/mcdc-structural-coverage-912

Conversation

@avrabe

@avrabe avrabe commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

RQ-57-MCDC. Closes the six-release N/A on #912 by answering the question it was
actually stuck on: which surface.

The surface, decided on evidence

#912's stated blocker was structural — witness measures MC/DC on a Wasm
artifact, synth emits ARM/RV32/A64 machine code, so "run witness on synth's
output" is a category error. That is correct about synth's output and beside
the point: the decisions that ship a miscompile are in synth's own Rust, and
those compile to Wasm fine.

REJECTED — native LLVM MC/DC via -Zcoverage-options=mcdc. Not a
preference; the capability was removed from rustc.
rust-lang/rust#144999
"coverage: Remove all unstable support for MC/DC instrumentation", merged
2025-08-08, Rust 1.91. Probed rather than assumed, on three nightlies
(1.93/1.95/1.97-nightly): mcdc is rejected, only block|branch|condition is
accepted. condition is the trap in that family — it builds, and yields
MC/DC Conditions 0, mcdc_records: 0, and zero llvm.instrprof.mcdc.*
intrinsics
in the emitted IR. cargo-llvm-cov 0.6.21 and 0.8.7 both still
pass mcdc and both fail the build.

REJECTED — witness over the Wasm fixtures synth compiles. It measures the
fixture. The discriminating test is whose source the gap rows name; gale's run
(on #912) named core::fmt::Formatter::pad_integral,
core::str::count::do_count_chars, <u64 as Display>::fmt,
pad_integral::write_prefix, wit_bindgen::rt::cabi_realloc — five
stdlib/bindgen frames and zero synth functions. A surface whose gap rows
cannot name a synth decision cannot notice a missing condition in one.

CHOSEN — witness over a wasm32-wasip1 build of synth's OWN crates.
crates/synth-mcdc-harness is a thin row driver: it links the real crates
and calls the real pub fns with inputs arriving through Wasm parameters. It
re-implements no predicate — a mirror would be exactly the vacuous-gate class
this hub keeps finding. Gap rows name synth_core::static_data_addr::resolve_owner,
synth_backend_riscv::alloc_validator::is_ret, and so on.

Unexpected upside: witness reconstructs decisions from the lowered br_if
chains, so a Rust matches! becomes a real multi-condition decision.
is_ret's matches!(op, Jalr { rd: ZERO, rs1: RA, imm: 0 }) scores as
4 conditions — source-level MC/DC would have seen nothing there.

"The right parts"

Scored, because a missed condition here has already shipped a soundness bug in
this repo
:

module class the bug
synth_core::static_data_addr validator accept/reject VCR-VER-003 #777 — exists to hard-error the #757 wrong-segment miscompile; also the #798 served-image gate
synth_backend_riscv::alloc_validator validator accept/reject VCR-RA-003 #815#871 shipped an unsaved-ra miscompile and the fix was a condition added to the save-set predicate
synth_backend_riscv::backend guard emission #953/#959mem_size == 0 was EXEMPT from the power-of-two mask gate, so (memory 0) emitted an identity mask (0-1 = 0xFFFF_FFFF) and every access ran unmasked

Excluded, named not hidden:

Measured — and the platform turned out to be part of the measurement

witness 0.42.0, wasm32-wasip1, rustc 1.96.1, the same 56 rows, two hosts:

host dec full cond proved gap dead
ubuntu-latest x86_64 (CI — the floors) 22 4 130 57 23 50
macOS aarch64 (development) 20 3 144 63 31 50

Same toolchain version, same witness, same rows — different host. These
count decisions reconstructed from lowered Wasm, so std inlining moves
them: validate_final_allocation_rv32 presents as 9 decisions / 44 conditions
on Linux and 4 / 43 on macOS, and ensure_supported_target disappears on Linux
entirely. Only dead is identical (50), as you'd expect of "never reached".

That was not predicted — it was measured, by the first CI run, after the
local baseline had been written down. Floors are therefore the CI numbers
(re-measured, not lowered to fit), with both baselines recorded in the script so
the macOS delta is a stated fact rather than a surprise: a developer running
this locally on macOS will not meet these floors, and that is a platform delta,
not a regression.

Witness-version invariance was checked separately (0.28.0 ≡ 0.42.0 on one host),
so the tool is not what moves these.

The gate reads gap rows, not a percentage. All 31 are printed with the
vector witness says would close them. Two residuals stated explicitly:
validate_final_allocation_rv32 carries 10- and 20-condition whole-function
chains (≥21 co-designed vectors each; not claimed), and
ensure_supported_target's ISA conjunction cannot be flipped through public
constructors (no TargetSpec has family RiscV with a non-RiscV32/64 ISA).
is_ret went 3-gap → 0-gap by adding exactly the three vectors witness
printed — the gap rows are actionable, not decorative.

Why the gate scores by FUNCTION and floors COUNTS

  1. The module-wide percentage is meaningless: a wasip1 link drags in
    wasi-libc + std, so the raw figure is 3/770 with 3879 dead conditions.
  2. witness's source_file/source_line cannot be used for scoping — they
    are DWARF attributions of inlined code (resolve_owner's decision reports
    as static_data_addr.rs:355; it is at :274) and only a basename, so six
    crates' backend.rs collide (upstream witness#179). The manifest's
    per-branch function_name is reliable, so the gate scopes on the
    demangled symbol.
  3. A ratio cannot notice a deleted condition — removing one removes its gap
    row and the percentage improves.

Declared floors = the CI platform's measured baseline, no slack:
decisions ≥ 22, conditions ≥ 130, proved ≥ 57, fully-proved ≥ 4,
dead ≤ 50.

Dead is ceilinged, not ignored. 50 scored conditions are never
evaluated — 40 in is_straight_line, whose match arms cover RV32 opcodes the
row set does not construct. Honest residual, but an unfloored residual is how
a number rots: a change that stopped reaching the segment barriers would raise
dead, lower nothing else, and pass.

Red-first potency — and the finding that a red gate proved nothing

Locally (macOS baseline 20 / 3 / 144 / 63 / 31 / 50), both restored byte-identical

(a) Delete a condition — remove the #871 fix || rs2 == Reg::RA:

mutated   dec 19  full 2  cond 142  proved 54  dead 52
FAIL x5 (decisions, conditions, proved, fully-proved, dead ceiling)

The condition-count drop is the signal a ratio-only floor cannot produce.

(b) Weaken the vector set — drop ONE row (ra_validate:14):

mutated   dec 20  full 2  cond 144  proved 62
FAIL: proved 62 < 63 ; fully-proved 2 < 3

Then on CI, where the gate actually blocks — because the local result does not obviously transfer when the same function presents as 9 decisions instead of 4

Probe 1 (mutation a) went red at the WRONG STEP. The job's own row-driver
sanity gate asserts ra_validate(4) == 1 (#871), so deleting the condition
fails there and the MC/DC measurement never ran; VCR-RA-003 RV32 went red
independently too. Three gates catching one mutation is good engineering and
no evidence that the MC/DC floors bite. Worth recording as a general
lesson: a red gate is not evidence that the gate you were testing works — read
which step failed.

Probe 2 (mutation b) isolated them — dropping a truth-table row touches no
compiler behaviour, so the failure must come from the scoring step or not at
all. Run 31821746035:

step 7  Row-driver sanity gate (host)                     success
step 8  Run the MC/DC rows under witness                  success
step 9  Score synth's own decisions against the floors    FAILURE

baseline  22  4  130  57  23  50
probe 2   22  3  130  56  24  50
FAIL: proved conditions 56 < floor 57
FAIL: fully-proved decisions 3 < floor 4

Decisions unchanged, conditions unchanged at 130 — nothing deleted — while
proved fell and one decision dropped out of full MC/DC. Coverage lost,
structure intact, caught by the MC/DC scoring step itself, on the platform the
gate blocks on.

Both probes are reverted; git diff 712f658d -- crates/ is empty and
scripts/mcdc_run.sh is byte-identical to the pre-probe tree.

Wiring

mcdc-structural-coverage CI job, witness pinned to v0.42.0, with a
non-vacuity assert (grep -qE '^TOTAL +[0-9]+') so an empty scored table cannot
print PASS. A host-side row-driver sanity test runs under cargo test --workspace,
so a driver that returned one verdict for every row would go red rather than
quietly make the whole MC/DC run vacuous.

The toolchain is pinned to 1.96.1, deliberately unlike every other job here:
the floors are counts of decisions and conditions reconstructed from lowered
Wasm
, so they are sensitive to how std inlines, and @stable is a moving
target that could red the gate with no code change. Bumping the pin is allowed
and obliges a re-measure, not a lowering. (The witness-version invariance
0.28 ≡ 0.42 is evidence about witness; it does not transfer to rustc.)

The CI runs caught three real things, recorded because they are the kind of
thing that otherwise ships: ./scripts/mcdc_run.sh exited 126 on a fresh
checkout (scripts/ is mode 644 here, cf. oracle_run.py; it only ran locally
because of a chmod); the job had been on @stable; and the floors were
host-dependent in a way no amount of local measurement would have revealed.

Gates

cargo test --workspace 0 · cargo clippy --workspace --all-targets -- -D warnings 0 ·
cargo fmt --check 0 · claim_check.py 43/43 (no new load-bearing doc claim,
so the ledger is unchanged).

Full evidence: scripts/repro/mcdc_912_gate.md.

Refs #912

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

RQ-57-MCDC. #912 sat N/A for six releases on a surface argument: `witness`
measures MC/DC on a Wasm artifact and synth emits ARM/RV32/A64 machine code,
so "run witness on synth's output" is a category error. That is true about
synth's OUTPUT and irrelevant — the decisions that ship a miscompile are in
synth's own Rust, and those compile to Wasm fine.

SURFACE, chosen on evidence (scripts/repro/mcdc_912_gate.md):

* REJECTED native LLVM MC/DC — not a preference, the capability was REMOVED
  from rustc (rust-lang/rust#144999, merged 2025-08-08, Rust 1.91). Probed on
  three nightlies: `mcdc` is rejected, only `block|branch|condition` accepted.
  `condition` is the trap in the family — it builds, and it emits ZERO
  `llvm.instrprof.mcdc.*` intrinsics and zero mcdc_records.
* REJECTED witness-over-the-fixtures-synth-compiles — it measures the fixture.
  gale's run named `pad_integral`, `do_count_chars`, `<u64 as Display>::fmt`,
  `write_prefix`, `cabi_realloc` and ZERO synth functions. A surface whose gap
  rows cannot name a synth decision cannot notice a missing condition in one.
* CHOSEN witness over a wasm32-wasip1 build of synth's OWN crates.
  `synth-mcdc-harness` is a thin row driver — it calls the REAL pub fns with
  inputs arriving through Wasm params; it re-implements no predicate.

THE RIGHT PARTS — scored where a missed condition HAS shipped a bug here:
static_data_addr (VCR-VER-003 #777/#757/#798), alloc_validator (VCR-RA-003
#815; #871's fix WAS a condition added to the save-set predicate), and the
RV32 bounds gate (#953/#959 — `mem_size == 0` was exempt from the power-of-two
check, so `(memory 0)` emitted an identity mask and every access ran unmasked).
EXCLUDED and named: instruction_selector (225 operator lines, its own lane);
the aarch64 bounds closures (#865 — residual); wcet declines (match-dispatch,
1 operator line in 1061, so MC/DC has nothing to say there).

MEASURED 20 decisions / 144 conditions / 63 proved / 31 gap / 50 dead, 3 at
full MC/DC. Identical under witness 0.28.0 and 0.42.0. Gap rows are PRINTED
with the closing vector — `is_ret` went 3-gap to 0-gap by adding exactly the
three vectors witness named.

THE GATE SCORES BY FUNCTION AND FLOORS COUNTS, for three reasons: the
module-wide figure is 3/770 because wasip1 links wasi-libc + std; witness's
`source_file` is an inlined-DWARF BASENAME and misattributes (resolve_owner's
decision reports as static_data_addr.rs:355, it is at :274 — witness#179); and
a ratio cannot notice a DELETED condition, which makes the percentage improve.

RED-FIRST, two mutations, two distinct failure paths, both restored
byte-identical:
  (a) delete `|| rs2 == Reg::RA` (the #871 fix)  -> 19/142/54/2, all four
      floors trip; the condition-COUNT drop is what a ratio cannot produce.
  (b) drop one truth-table row (ra_validate:14)  -> conditions stay 144,
      proved 63->62 and fully-proved 3->2.

Wired as the `mcdc-structural-coverage` CI job, witness pinned to v0.42.0,
with a non-vacuity assert so an empty scored table cannot print PASS.

Refs #912

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
avrabe and others added 4 commits August 14, 2026 18:46
… dead count

Three fixes from the first CI run and the cold read of it:

1. exit 126. `scripts/` is mode 644 in this repo (cf. scripts/oracle_run.py),
   so `./scripts/mcdc_run.sh` cannot exec on a fresh checkout even though it
   runs locally after a chmod. Invoke through `bash`, matching how every other
   script in this workflow is called.

2. PIN the toolchain to 1.96.1. The floors are counts of decisions and
   conditions RECONSTRUCTED FROM LOWERED WASM, so they are sensitive to how
   `std` inlines. `@stable` is a moving target and a Rust release could red
   this gate with no code change. Bumping the pin is allowed and obliges a
   RE-MEASURE, not a lowering. (Witness-version invariance — 0.28 = 0.42 — is
   evidence about witness, not about rustc; it does not transfer.)

3. CEILING the dead count at the measured 50. 50 of 144 scored conditions are
   never evaluated (40 in `is_straight_line`, whose match arms cover RV32
   opcodes the row set does not construct). That residual was the one number in
   the table nothing guarded: a change that stopped reaching the segment
   barriers would raise `dead`, lower nothing else, and PASS. It is also a
   third potency surface — mutation (a) moves dead 50 -> 52, so the mutated run
   now trips five floors instead of four.

Also trims the `sd_resolve_owner` doc comment, which claimed vectors the
measurement does not show: its 2 gap conditions are now stated as a measured
residual rather than implied closed.

Baseline re-verified unchanged: 20 / 3 / 144 / 63 / 31 / 50, PASS.

Refs #912

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
Reverted by the very next commit. Two things in one CI run:

1. RE-MEASURE the floors on the platform the gate actually runs on. The first
   CI run proved what could only be argued locally: these counts are
   PLATFORM-dependent, not just toolchain-version dependent.

     ubuntu x86_64, rustc 1.96.1, witness 0.42.0:  22 / 130 / 57 / 23 gap / 50 dead, 4 full
     macOS aarch64, rustc 1.96.1, witness 0.42.0:  20 / 144 / 63 / 31 gap / 50 dead, 3 full

   Same toolchain version, same witness, same 56 rows, different host.
   `validate_final_allocation_rv32` presents as 9 decisions / 44 conditions on
   Linux and 4 / 43 on macOS; `ensure_supported_target` vanishes on Linux.
   Floors move to the CI numbers — RE-MEASURED, not lowered to fit. Both
   baselines are recorded in the script so the macOS delta is a stated fact
   rather than a surprise.

2. RE-PROVE POTENCY ON THAT PLATFORM. The local mutation evidence does NOT
   obviously transfer when the same function presents as 9 decisions instead
   of 4 — and "it obviously still works" is the reasoning this whole lane
   exists to distrust. So this commit ALSO deletes the #871 condition
   `|| rs2 == Reg::RA` from the RV32 allocation validator's save-set
   predicate. The MC/DC job must go RED on the condition-COUNT floor, and the
   VCR-RA-003 RV32 job should go red independently.

   If this commit is green, the gate is vacuous and the lane is wrong.

Refs #912

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…mmit

Probe 1 (25a9195) went RED on CI as required, but at the WRONG STEP for the
evidence I wanted: the row-driver sanity gate (step 7) asserts
`ra_validate(4) == 1` (#871: unsaved ra must be a violation), so deleting
`|| rs2 == Reg::RA` fails there and the MC/DC measurement never runs. Two
independent gates catching one mutation is good engineering and useless as
proof that the MC/DC FLOORS bite.

This probe isolates them. It restores the source byte-identical (verified:
empty `git diff` vs the pre-mutation tree) and instead drops ONE truth-table
row — `ra_validate:14`, the non-sp `Lw` that gives `sp_slot_load` its
unique-cause pair. That touches no compiler behaviour, so:

  step 7 (row-driver sanity)  must PASS
  step 8 (witness run)        must PASS
  step 9 (MC/DC floors)       must FAIL on  and

Conditions must stay at 130 — coverage lost, nothing deleted. That is the
second of the two potency paths, and the one that proves the SCORING floor
itself is what goes red, on the platform it actually runs on.

Refs #912

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
Both red-first probes reverted; the tree is byte-identical to 712f658 for
crates/synth-backend-riscv/src/alloc_validator.rs and scripts/mcdc_run.sh
(verified with an empty \$(git diff 712f658 -- <path>) on each).

What the two CI probes established, which the local run could not:

PROBE 1 (delete the #871 condition) went RED — at the WRONG STEP. The job's own
row-driver sanity gate asserts ra_validate(4) == 1 and fails there, so the MC/DC
measurement never ran; the VCR-RA-003 RV32 job went red independently too. Three
gates catching one mutation is good engineering and NO evidence that the MC/DC
floors bite. Recording it because 'a red gate is not evidence that the gate you
were testing works' generalises well past this lane — read WHICH STEP failed.

PROBE 2 (drop one truth-table row, no compiler behaviour touched) isolated them.
Run 31821746035: sanity gate PASS, witness run PASS, SCORING STEP FAIL.

  baseline  22  4  130  57  23  50
  probe 2   22  3  130  56  24  50
  FAIL: proved conditions 56 < floor 57
  FAIL: fully-proved decisions 3 < floor 4

Decisions unchanged, CONDITIONS UNCHANGED at 130 — nothing deleted — while
proved fell and a decision dropped out of full MC/DC. That is the signature of
coverage lost with structure intact, produced by the MC/DC scoring step itself,
on the platform the gate blocks on.

Refs #912

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.22879% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-mcdc-harness/src/lib.rs 99.22% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit 778b364 into main Aug 14, 2026
59 checks passed
@avrabe
avrabe deleted the feat/mcdc-structural-coverage-912 branch August 14, 2026 17:13
avrabe added a commit that referenced this pull request Aug 14, 2026
…e 4 shipped (#979)

Two things. The re-grades are bookkeeping; the back-fill is a finding.

RE-GRADED to `implemented` (work merged on main):
  RQ-57-COUNTPARAMS  #970  (#974)   ARM+RV32 conditional-param miscompile
  RQ-57-GPIO         #846  (#976)   gpio-thin 502 -> 494 B
  RQ-57-ARMSEM       #923  (#975)   ArmSemantics no-oped 87 of 222 ops
  RQ-57-MCDC         #912  (#978)   MC/DC over synth's own decision logic

RQ-57-BACKFILL: the back-fill was NOT performed, on evidence.

263 of 288 artifacts carry no `release:`. The prescribed derivation — "the
first tag containing the artifact" — RUNS FINE (0 undecidable, 24 distinct
tags) and answers the WRONG QUESTION. It yields when an artifact ENTERED THE
PLAN; `release:` means the release the work is TARGETED AT or SHIPPED IN.

The file supplied its own control case, which is what settles it:

  VCR-RA-001, hand-set          release: v0.24.0   <- when the work shipped
  VCR-RA-001, mechanical rule   v0.11.30           <- introducing commit's tag

It is the ONLY artifact in verified-codegen-roadmap.yaml that already carried a
`release:`, and the rule contradicts it. Sweeping the other 32 would have
written 32 false values with the one correct value sitting beside them as the
disproof.

Scale, had it been applied blindly: 190 of the 263 resolve to v0.1.1 — the
initial import, i.e. the standing requirement base (architecture, stakeholder
and system requirements, component model, target platforms). Tagging those
v0.1.1 asserts the whole foundational base was targeted at the first tag, and
makes "what is in v0.1.1?" return 190 artifacts including work that shipped
forty releases later. That is the corruption of the readiness query this
artifact exists to prevent — so the artifact's own guardrail ("stays
unassigned rather than guessed", the #911 lesson applied to planning data)
decides it.

CONVENTION, now documented in docs/release-process.md so the absence stops
being re-filed as an unfinished chore:
  * per-release plan artifacts carry `release:` (they are work items; they do)
  * standing artifacts carry it ONLY where the shipping release is known, as
    VCR-RA-001 does
  * setting it on a standing artifact is a per-artifact judgement with
    CHANGELOG evidence, never a sweep

A missing `release:` is a justified state. A wrong one is worse than a missing
one.

rivet: 50 errors / 166 warnings before AND after — unchanged. claim_check 43/43.

Refs #912


Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Aug 18, 2026
#978 pinned the MC/DC job to `dtolnay/rust-toolchain@1.96.1` for a stated
reason: its floors (decisions / conditions / proved / dead) are derived from
that exact compiler on the CI host, and the pin exists so a Rust release cannot
red the gate with no code change.

#984 bumped it to @1.100.0 — as if it were a routine action-version update.

FOR THIS ACTION THE REF IS THE COMPILER. So a bot bump that reads like
"dependabot: bump action from X to Y" silently changes which rustc the MC/DC
surface is built with, and every subsequent run measures a DIFFERENT compiler
against 1.96.1-derived floors. The pin was defeated by the one update shape
nobody inspects.

It is already biting: PR #994 (this branch, before this commit) failed exactly
one check — MC/DC — for no reason other than being based on a main that now
installs 1.100.0.

Two changes:

  * .github/workflows/ci.yml — restored @1.96.1, with the reason inline at the
    pin so the next reader does not have to reconstruct it from two issues.
  * .github/dependabot.yml — `ignore: dtolnay/rust-toolchain` for the
    github-actions ecosystem. Bumping it is a deliberate act that must
    RE-MEASURE the floors, which is not a bot's job.

This is the same class as the 0.x-minor rule (#849/#965): an update whose
CATEGORY is wrong, so the automation's category-based judgement is wrong too.
There the fix was "hold 0.x-minor because minor IS major for 0.x"; here it is
"this action's ref is not an action version at all".

claim_check 47/47.

Refs #242, #912, #978

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant